Skip to content

Conversation

@fennb
Copy link
Contributor

@fennb fennb commented Sep 29, 2025

Addresses #1783.

Adds support for MCP Resources to MCPServer.

Changes

Resource Methods

  • list_resources() - Discover available resources on the server
  • list_resource_templates() - List parameterized resource templates (RFC 6570)
  • read_resource(uri) - Read resource contents, accepting string URI or Resource object

Native Types

  • Introduces Resource, ResourceTemplate, ResourceAnnotations, and ServerCapabilities types
  • Automatically converts text content to str and binary content to BinaryContent
  • Avoids exposing upstream mcp library types in the public API

Error Handling

  • Introduces native MCP exception type MCPError
  • Captures MCP SDK errors context (error code and data)

Other

  • Added MCPServer.capabilities property to inspect server features
  • Updated MCP dependency to mcp>=1.18.0 (required for Annotations tests)

@DouweM DouweM self-assigned this Sep 30, 2025
@DouweM DouweM requested a review from Kludex September 30, 2025 20:54
@phemmer
Copy link
Contributor

phemmer commented Oct 12, 2025

I was just looking at this as I need resources support, and noticed this doesn't provide any way to determine whether the server even supports resources. The only way to tell with this code would be to attempt to make a resource call (e.g. read_resource) and wait for a failure.

I think this should be better exposed as I would like my application to switch to alternate behavior if the MCP server does not support resources. And would like to do so without having to make a call and see if it fails, and then differentiate whether that failure was because of lack of support, or some other reason.

The MCP spec says that the resources capability is used to advertise resources support. However from looking at the code, only serverInfo is captured from the initialize result, and the rest of the initialize response (which includes resources support) is thrown away.

https://github.com/fennb/pydantic-ai/blob/b8424d80eeeb46aef3ad8ae49ae29b1ef820c7af/pydantic_ai_slim/pydantic_ai/mcp.py#L375-L378

@phemmer
Copy link
Contributor

phemmer commented Oct 12, 2025

Another thing I noticed going through the code, is that for EmbeddedResource handling, the metadata is being thrown away. I need to know the URI of the resource.

I'm not sure if addressing this is even in scope of this PR, as ResourceLink handling is also bad, as it automatically fetches the content instead of providing the link. But issue #3099 addresses this. So I'm not sure if this EmbeddedResource metadata loss issue goes here, there, or in a new issue entirely. But figured it would be at least worth mentioning here.

@fennb
Copy link
Contributor Author

fennb commented Oct 12, 2025

The MCP spec says that the resources capability is used to advertise resources support. However from looking at the code, only serverInfo is captured from the initialize result, and the rest of the initialize response (which includes resources support) is thrown away.

That's a great point (and not something I was looking at in my changes). Capabilities presumably hasn't been an issue up until this point because MCP was only used for tools in Pydantic AI and it was assumed you wouldn't use an MCP server unless it had tool capabilities.

I'm not quite sure the right way to support this. The upstream mcp library has types for each capability, but replicating/proxying these as native types seems quite verbose.

There is an existing pattern, which is how server_info() is handled (as you point out @phemmer), where the details are stored/cached at initalize time and exposed like so:

@property
    def server_info(self) -> mcp_types.Implementation:
        """Access the information send by the MCP server during initialization."""
        if getattr(self, '_server_info', None) is None:
            raise AttributeError(
                f'The `{self.__class__.__name__}.server_info` is only instantiated after initialization.'
            )
        return self._server_info

We could follow this pattern and just return mcp SDK types. Alternatively, since we don't support listChanged anyway, could we maybe just make them a set of ServerCapabilities literals?

Maybe something like:

ServerCapabilities = Literal[
      "experimental",
      "logging",
      "prompts",
      "resources",
      "tools",
      "completions"
  ]

# MCPServer
@property
def capabilities() -> set[ServerCapabilities]:
    ...

Meh, I guess that's kind ugly: if "resources" in server.capabilities(): ... - perhaps the way mcp already works is superior, so we'd end up with something more like: if server.capabilities.resources: ...? I'm not familiar enough to know what would be more idiomatic for Pydantic.

I don't want this PR to drag on any longer than it already has, but this seems like a reasonable/incremental change overall?

What do you think @DouweM / @Kludex ?

@fennb
Copy link
Contributor Author

fennb commented Oct 12, 2025

I'm not sure if addressing this is even in scope of this PR, as ResourceLink handling is also bad, as it automatically fetches the content instead of providing the link. But issue #3099 addresses this.

Yep yep - do you mind moving this topic across to #3099 ?

@DouweM
Copy link
Collaborator

DouweM commented Oct 13, 2025

So I'm not sure if this EmbeddedResource metadata loss issue goes here, there, or in a new issue entirely.

@phemmer We have an issue for that on already: #2288. Input on what the better behavior would be is much appreciated!

@DouweM
Copy link
Collaborator

DouweM commented Oct 13, 2025

Meh, I guess that's kind ugly: if "resources" in server.capabilities(): ...

@fennb I don't mind it! @Kludex You?

@Kludex
Copy link
Member

Kludex commented Oct 13, 2025

What happens when you try to retrieve resources from a server that doesn't have that capability?

@fennb
Copy link
Contributor Author

fennb commented Oct 14, 2025

What happens when you try to retrieve resources from a server that doesn't have that capability?

Right now (current state) it would raise an mcp.shared.exceptions.McpError (ie: MCP SDK class error), with the specifics of the error being dependent on server itself.

Arguably we could choose to intercept this and fail-early if the server doesn't have the capability, but I'm not sure it really adds any value? There's also not much of a pattern in the codebase for this that I can tell (given how lightweight exceptions.py is).

I think we can just leave it up to the client application/user to check which server capabilities exist via something like a capabilities() property before they try to use them (particularly given Resources are supposed to be application controlled, not model controlled), yes?

@DouweM
Copy link
Collaborator

DouweM commented Oct 15, 2025

Since "resources are not supported" or "the requested resource doesn't exist" are such common cases users will typically need to handle, I think they should be our own exceptions or the methods could just return [] / None. I'm fine with that as well, you @Kludex?

@fennb
Copy link
Contributor Author

fennb commented Oct 16, 2025

Since "resources are not supported" or "the requested resource doesn't exist" are such common cases users will typically need to handle, I think they should be our own exceptions or the methods could just return [] / None. I'm fine with that as well, you @Kludex?

I've done some additional testing. The specifics of errors are pretty server-implementation specific. For example, the official python-sdk's FastMCP server doesn't even have the ability to disable Resource capability advertisement and just returns [] if you call list_resources() on it if none are defined.

read_resource() on a non-existent resource raises an McpError with error = ErrorData(code=0, message='Unknown resource: not://here', data=None). Amusingly, this appears to violate the MCP spec, which says that a not-found should be code -32002.

I think returning None for list_resources() if there's no support makes sense/is simplest.

For other errors in things like read_resource() (ultimately McpError), I guess it does make sense to raise native exception types (same as returning our own data types).

I'll take a pass at this direction to review.

@Kludex
Copy link
Member

Kludex commented Oct 17, 2025

I'm fine with that.

@fennb fennb requested a review from DouweM November 5, 2025 11:07
@fennb fennb requested a review from Kludex November 6, 2025 02:03
@fennb
Copy link
Contributor Author

fennb commented Nov 6, 2025

This is ready for re-review. I noticed we're consistently getting test failures on a specific subset of test suites, but they really seem unrelated to these changes as far as I can see? Example here.

Comment on lines 83 to 95
@classmethod
def from_mcp_sdk_error(cls, error: mcp_exceptions.McpError) -> MCPError:
"""Create an MCPError from an MCP SDK McpError.
Args:
error: An McpError from the MCP SDK.
Returns:
A new MCPError instance with the error data.
"""
# Extract error data from the McpError.error attribute
error_data = error.error
return cls(message=error_data.message, code=error_data.code, data=error_data.data)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to do this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just because we're returning our own/native MCPError type, rather than the mcp-sdk one and we're flattening it out. The MCP SDK error type has a nested error element as it exactly mirrors the protocol JSON, whilst ours is flattened out.

Apologies if I've misinterpreted your question 🙂.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not sure this is necessary, since the developer has access to the exception cause, since we are doing raise ... from e.

Do we have a test demonstrates how useful this would be?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mostly added the ... from e to preserve the originating stack trace. You are right though, aside from the stack trace, our native exceptions literally replicate the data in the original exception 1:1.

If we don't think the originating stack trace is useful, I can remove it.

I still think it's useful to have the actual error details in the exception being thrown because it's easier for the user to access for "normal" error handling, eg:

try:
    ...
except MCPError as e:
    print(f'Something terrible happened reading your resource: {e.message} (err: {e.code})')

Lemme know if I'm missing something!

try:
result = await self._client.read_resource(AnyUrl(resource_uri))
except mcp_exceptions.McpError as e:
# As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can explain a bit more here...

Suggested change
# As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling
# As per https://modelcontextprotocol.io/specification/2025-06-18/server/resources#error-handling
# `-32002` defines "resource not found".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with @DouweM regarding raising an error vs returning None.

In this method, we can already see that None means different things:

  1. resource is not supported by the server.
  2. resource not found.
  3. no content in resource.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I think we can leverage the exceptions on the future exception handler mechanism.

Copy link
Contributor Author

@fennb fennb Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with @DouweM regarding raising an error vs returning None.

Yeah, I can see arguments in both directions. FWIW, I think returning [] or None when the server doesn't have resource capabilities is reasonable, ie: list_resources() returning [] is materially the same and if you can't get any resource URIs, then you can't really call read_resource() anyway. I'm not sure raising an exception here adds much value for the user.

If a client really needs to know if a server supports resources (maybe to display UI elements or something) you can still access mcp_server.capabilities.

On returning None vs a ResourceNotFound - This is trickier. One related issue we have is that so far, none of the main reference MCP server implementations (typescript, python) actually implement the protocol spec correctly anyway (though it looks like a python fix might be on the way), which means it's hard to detect a real "not found".

FWIW 2, our closest related precedence for this is probably direct_tool_call(), however, it raises ModelRetry regardless of the actual underlying error, which arguably is also not correct if a user is calling it directly?

Maybe the right model for this is (roughly) how HTTP error codes are commonly handled? Rather than having different subclasses (ie: ResourceNotFound, ServerError, etc) we raise a generic error (maybe just MCPError), which has a code and a message and let users handle flow based on the specific codes they care about (much like users would with HTTP response codes 400-599)? ie:

try:
    data = server.read_resource('url://here')
except MCPError as e:
    print(f'An error occurred reading your resource: {e.message} (err: {e.code})')

Maybe even define some useful common code maps?

RESOURCE_NOT_FOUND = -32002
INVALID_REQUEST = -32600

@Kludex you would have a better sense than I on this sort of thing 😄

Re:

Also, I think we can leverage the exceptions on the future exception handler mechanism.

Sorry, not quite sure what you mean by this - could you clarify?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just when you thought this couldn't get even more annoying, I just stumbled on this: modelcontextprotocol/modelcontextprotocol#1545

It turns out the spec itself is wrong/has a typo ☹️. From my read, it appears it's currently undecided on whether they're going to fix it or not (in the current protocol version or otherwise).

This is probably a vote in the direction of raising errors with codes and letting clients work out what they want to do with them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kludex gentle nudge?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there are stable version of PydanticAI with MCP Resources ready to use yet? If not when will you have something stable with basic MCP resource functionality released?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy with list_resources() returning empty list.

And I'm fine with this one returning MCPError instead of None. 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thank you! I’ll proceed with this asap.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy with list_resources() returning empty list.

And I'm fine with this one returning MCPError instead of None. 👍

Both done as of latest changes!

@fennb
Copy link
Contributor Author

fennb commented Nov 20, 2025

@Kludex - This is ready for re-review. Changes should be implemented as proposed/discussed here (along with addressing the other prior review comments).

Let me know if there are any concerns/changes desired!

@fennb fennb requested a review from Kludex November 20, 2025 09:43
@fennb
Copy link
Contributor Author

fennb commented Nov 20, 2025

FWIW, the test failure on 3.12 appears unrelated to my changes (though seems to occur reliably).

@DouweM DouweM merged commit 24e47bf into pydantic:main Nov 20, 2025
60 of 64 checks passed
@DouweM
Copy link
Collaborator

DouweM commented Nov 20, 2025

@fennb Thanks Fenn, I've gone ahead and merged it and we'll see if the error occurs on main (I've also seen it happen somewhere in between flaky and reliably depending on the PR :/)

@fennb
Copy link
Contributor Author

fennb commented Nov 20, 2025

@fennb Thanks Fenn, I've gone ahead and merged it and we'll see if the error occurs on main (I've also seen it happen somewhere in between flaky and reliably depending on the PR :/)

Thanks @DouweM @Kludex for your help/guidance on this one (and sorry it dragged on a bit). Let me know if there's anything else (related or otherwise) I can help out with. My general plan is to turn my attention to #3099 next.

@DouweM
Copy link
Collaborator

DouweM commented Nov 20, 2025

@fennb Thanks Fenn! I'm also curious about your take on #3339, it's related about how we expose MCP metadata through Pydantic AI objects (if not to the LLM itself).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants